<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>FPS 练枪 - three.js</title>
  <style>
    html, body { height: 100%; margin: 0; background: #0b0d12; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; }
    #app { position: fixed; inset: 0; }
    #ui { position: fixed; inset: 0; display: grid; place-items: center; pointer-events: none; }
    .panel { pointer-events: auto; background: linear-gradient(180deg, rgba(16,22,31,.85), rgba(10,14,20,.72)); border: 1px solid rgba(255,255,255,.08); padding: 22px 26px; border-radius: 16px; text-align: left; box-shadow: 0 8px 30px rgba(0,0,0,.45); backdrop-filter: blur(10px); width: min(900px, 92vw); }
    .panel .title { margin-bottom: 6px; font-weight: 700; font-size: 18px; text-align: center; }
    .panel .sub { margin-bottom: 16px; opacity: .8; text-align: center; }
    .form { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
    .field { display: flex; flex-direction: column; gap: 6px; }
    .col-2 { grid-column: 1 / -1; }
    .labelRow { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 13px; opacity: .9; }
    .badge { background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12); padding: 2px 8px; border-radius: 999px; font-size: 12px; opacity: .9; }
    .input, .form input[type="number"] { appearance: none; background: #121a26; border: 1px solid rgba(255,255,255,.1); color: #e5e7eb; border-radius: 10px; padding: 8px 10px; font-size: 14px; outline: none; transition: border-color .15s ease, box-shadow .15s ease; }
    .form input[type="number"]:focus { border-color: rgba(99,179,237,.7); box-shadow: 0 0 0 3px rgba(99,179,237,.15); }
    .range { -webkit-appearance: none; height: 6px; border-radius: 999px; background: linear-gradient(90deg, #69a9ff, #4ac6b7); outline: none; }
    .range::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #e5e7eb; border: 2px solid #111826; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,.3); }
    .btn { appearance: none; background: linear-gradient(180deg, #2a3446, #1b2230); color: #e5e7eb; border: 1px solid rgba(255,255,255,.16); border-radius: 12px; padding: 12px 16px; cursor: pointer; font-size: 16px; width: 100%; transition: transform .06s ease, background .2s ease, border-color .2s ease; }
    .btn:hover { background: linear-gradient(180deg, #324056, #20293a); border-color: rgba(255,255,255,.24); }
    .btn:active { transform: translateY(1px); }
    #hud { position: fixed; top: 10px; left: 10px; display: flex; gap: 12px; align-items: center; font-size: 14px; opacity: .9; }
    #crosshair { position: fixed; left: 50%; top: 50%; width: 18px; height: 18px; margin-left: -9px; margin-top: -9px; pointer-events: none; opacity: .9; }
    #crosshair::before, #crosshair::after { content: ""; position: absolute; background: #e5e7eb; }
    #crosshair::before { left: 8px; top: 0; width: 2px; height: 18px; border-radius: 1px; }
    #crosshair::after { left: 0; top: 8px; width: 18px; height: 2px; border-radius: 1px; }
    #reticle { position: fixed; bottom: 10px; right: 10px; font-size: 12px; opacity: .6; }
    /* Records sidebar */
    #records { position: fixed; right: 16px; top: 16px; width: 260px; z-index: 10; }
    #records .card { background: linear-gradient(180deg, rgba(16,22,31,.8), rgba(10,14,20,.68)); border: 1px solid rgba(255,255,255,.08); border-radius: 14px; padding: 14px; box-shadow: 0 6px 22px rgba(0,0,0,.35); backdrop-filter: blur(8px); }
    #records .card h4 { margin: 0 0 8px 0; font-weight: 700; font-size: 14px; opacity: .95; }
    #records .meta { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; font-size: 12px; opacity: .9; }
    #records .value { text-align: right; opacity: .95; }
    /* Weapon recoil */
    @keyframes weaponKick {
      0% { transform: translate3d(0,0,0) rotate(0deg) scale(1) translate(2px, 2px); filter: brightness(1); }
      12% { transform: translate3d(-0.2vw, 1.6vh, 0) rotate(-3deg) scale(1.02) translate(12px, 12px); filter: brightness(1.05); }
      100% { transform: translate3d(0,0,0) rotate(0deg) scale(1) translate(2px, 2px); filter: brightness(1); }
    }
    #weaponImg.kick { animation: weaponKick 140ms cubic-bezier(.23,1,.32,1) 1; }
    /* Results panel */
    #resultPanel { display:none; pointer-events:auto; background: linear-gradient(180deg, rgba(16,22,31,.9), rgba(10,14,20,.78)); border: 1px solid rgba(255,255,255,.08); padding: 22px 26px; border-radius: 16px; text-align: left; box-shadow: 0 8px 30px rgba(0,0,0,.45); backdrop-filter: blur(10px); width: min(600px, 92vw); }
    #resultPanel .title { margin-bottom: 6px; font-weight: 700; font-size: 18px; text-align: center; }
    #resultPanel .grid { display:grid; grid-template-columns: 1fr 1fr; gap:10px; margin: 12px 0 16px; font-size: 14px; }
    #resultPanel .row { display:flex; justify-content: space-between; opacity:.95; }
    #resultActions { display:flex; gap:10px; }
  </style>
  <script type="importmap">
  {
    "imports": {
      "three": "./three.module.js",
      "three/addons/": ".jsm/"
    }
  }
  </script>
</head>
<body>
  <div id="app"></div>
  <div id="ui">
    <div id="startPanel" class="panel">
      <div class="title">FPS 练枪</div>
      <div class="sub">点击开始进入第一人称，射击指定数量气球结束</div>
      <div class="form" style="margin-bottom: 14px;">
        <div class="field col-2">
          <div class="labelRow"><span>中心范围(NDC 半径)</span><span id="optNdcVal" class="badge">0.25</span></div>
          <input id="optNdc" class="range" type="range" min="0.05" max="0.6" step="0.01" value="0.25">
        </div>
        <div class="field">
          <label class="labelRow">距离最小(m)</label>
          <input id="optDistMin" class="input" type="number" step="0.5" value="2">
        </div>
        <div class="field">
          <label class="labelRow">距离最大(m)</label>
          <input id="optDistMax" class="input" type="number" step="0.5" value="8">
        </div>
        <div class="field">
          <label class="labelRow">目标数量</label>
          <input id="optTargets" class="input" type="number" min="1" step="1" value="20">
        </div>
        <div class="field">
          <label class="labelRow">气球最小直径(m)</label>
          <input id="optSizeMin" class="input" type="number" step="0.01" value="0.28">
        </div>
        <div class="field">
          <label class="labelRow">气球最大直径(m)</label>
          <input id="optSizeMax" class="input" type="number" step="0.01" value="0.38">
        </div>
      </div>
      <button id="startBtn" class="btn">开始</button>
    </div>
    <div id="resultPanel" class="panel" style="display:none;">
      <div class="title">本次成绩</div>
      <div class="grid">
        <div class="row"><span>命中数</span><span id="resHits">0</span></div>
        <div class="row"><span>射击数</span><span id="resShots">0</span></div>
        <div class="row"><span>命中率</span><span id="resAccuracy">0%</span></div>
        <div class="row"><span>完成时间</span><span id="resTime">0.000s</span></div>
        <div class="row"><span>目标数量</span><span id="resTargets">0</span></div>
      </div>
      <div id="resultActions">
        <button id="retryBtn" class="btn">重新练习</button>
        <button id="backBtn" class="btn">返回</button>
      </div>
    </div>
  </div>
  <div id="hud" style="display:none;">
    <div id="counter">0 / 20</div>
    <div id="timer">00.000s</div>
  </div>
  <div id="crosshair" style="display:none;"></div>
  <img id="weaponImg" src="手持枪械.png" alt="" style="position:fixed; right:30%; bottom:0; width:55vw; max-width:1000px; height:auto; pointer-events:none; user-select:none; display:none; z-index:9;" />
  <div id="reticle">左键射击 · 鼠标锁定视角 · WASD移动</div>
  <div id="records">
    <div class="card">
      <h4>练习记录</h4>
      <div class="meta">
        <div>累计完成</div><div class="value" id="rAttempts">0 次</div>
        <div>最佳时间</div><div class="value" id="rBestTime">--</div>
        <div>最佳命中率</div><div class="value" id="rBestAcc">--</div>
        <div>最佳命中数</div><div class="value" id="rBestHits">--</div>
        <div>最佳射击数</div><div class="value" id="rBestShots">--</div>
        <div>记录日期</div><div class="value" id="rBestDate">--</div>
      </div>
    </div>
  </div>

  <script type="module">
    import * as THREE from 'three';
    import { PointerLockControls } from './jsm/controls/PointerLockControls.js';

    const app = document.getElementById('app');
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    app.appendChild(renderer.domElement);

    const scene = new THREE.Scene();
    scene.background = new THREE.Color('#0b0d12');

    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    camera.position.set(0, 1.6, 3);

    const controls = new PointerLockControls(camera, renderer.domElement);

    // ground & lights
    const hemi = new THREE.HemisphereLight(0xffffff, 0x222233, 1.1);
    scene.add(hemi);
    const dir = new THREE.DirectionalLight(0xffffff, 0.6);
    dir.position.set(5, 10, 2);
    scene.add(dir);

    const groundGeo = new THREE.PlaneGeometry(200, 200);
    const groundMat = new THREE.MeshStandardMaterial({ color: 0x0e1420, roughness: 1 });
    const ground = new THREE.Mesh(groundGeo, groundMat);
    ground.rotation.x = -Math.PI/2;
    ground.position.y = 0;
    ground.receiveShadow = false;
    scene.add(ground);

    const grid = new THREE.GridHelper(200, 200, 0x335577, 0x1b2b3a);
    grid.material.opacity = 0.15; grid.material.transparent = true;
    scene.add(grid);

    // player movement state
    const move = { forward:false, backward:false, left:false, right:false }; 
    let velocity = new THREE.Vector3();
    const speed = 7; // m/s
    let playing = false;

    // balloons pool
    const balloonGroup = new THREE.Group();
    scene.add(balloonGroup);
    const raycaster = new THREE.Raycaster();

    let totalTargets = 20;
    const options = {
      ndcBox: 0.25,
      distMin: 4,
      distMax: 10,
      sizeMin: 0.18,
      sizeMax: 0.28,
    };
    let hitCount = 0;
    let shotsCount = 0;
    let startTime = 0;
    let finishedResult = null; // { completed: boolean }

    // storage helpers
    const STORAGE_KEY = 'aim_trainer_stats_v1';
    function loadStats(){
      try{
        const raw = localStorage.getItem(STORAGE_KEY);
        return raw ? JSON.parse(raw) : { attempts: 0, best: null };
      }catch(e){ return { attempts: 0, best: null }; }
    }
    function saveStats(data){
      try{ localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); }catch(e){}
    }
    function renderRecords(){
      const s = loadStats();
      rAttempts.textContent = `${s.attempts} 次`;
      if (s.best){
        rBestTime.textContent = `${(s.best.timeMs/1000).toFixed(3)}s`;
        rBestAcc.textContent = `${Math.round(s.best.accuracy*100)}%`;
        rBestHits.textContent = `${s.best.hits}`;
        rBestShots.textContent = `${s.best.shots}`;
        rBestDate.textContent = new Date(s.best.dateISO).toLocaleString();
      } else {
        rBestTime.textContent = '--'; rBestAcc.textContent = '--'; rBestHits.textContent = '--'; rBestShots.textContent = '--'; rBestDate.textContent = '--';
      }
    }
    

    const hud = document.getElementById('hud');
    const counterEl = document.getElementById('counter');
    const timerEl = document.getElementById('timer');
    const crosshair = document.getElementById('crosshair');
    const ui = document.getElementById('ui');
    const startPanel = document.getElementById('startPanel');
    const startBtn = document.getElementById('startBtn');
    const resultPanel = document.getElementById('resultPanel');
    const retryBtn = document.getElementById('retryBtn');
    const backBtn = document.getElementById('backBtn');

    // records elements
    const rAttempts = document.getElementById('rAttempts');
    const rBestTime = document.getElementById('rBestTime');
    const rBestAcc = document.getElementById('rBestAcc');
    const rBestHits = document.getElementById('rBestHits');
    const rBestShots = document.getElementById('rBestShots');
    const rBestDate = document.getElementById('rBestDate');
    renderRecords();

    // bind options UI
    const optNdc = document.getElementById('optNdc');
    const optNdcVal = document.getElementById('optNdcVal');
    const optDistMin = document.getElementById('optDistMin');
    const optDistMax = document.getElementById('optDistMax');
    const optTargets = document.getElementById('optTargets');
    const optSizeMin = document.getElementById('optSizeMin');
    const optSizeMax = document.getElementById('optSizeMax');

    function syncOptionDisplay(){ optNdcVal.textContent = Number(options.ndcBox).toFixed(2); }
    function readOptions(){
      options.ndcBox = Math.max(0.02, Math.min(0.8, Number(optNdc.value)));
      options.distMin = Math.max(1, Number(optDistMin.value));
      options.distMax = Math.max(options.distMin + 0.1, Number(optDistMax.value));
      options.sizeMin = Math.max(0.01, Number(optSizeMin.value));
      options.sizeMax = Math.max(options.sizeMin + 0.01, Number(optSizeMax.value));
      totalTargets = Math.max(1, Math.floor(Number(optTargets.value)) || 1);
      syncOptionDisplay();
    }
    readOptions();

    optNdc.addEventListener('input', ()=>{ options.ndcBox = Number(optNdc.value); syncOptionDisplay(); });
    for (const el of [optDistMin, optDistMax, optTargets, optSizeMin, optSizeMax]){
      el.addEventListener('change', readOptions);
      el.addEventListener('blur', readOptions);
    }

    startBtn.addEventListener('click', () => {
      readOptions();
      controls.lock();
    });
    retryBtn?.addEventListener('click', ()=>{ readOptions(); controls.lock(); });
    backBtn?.addEventListener('click', ()=>{ resultPanel.style.display='none'; startPanel.style.display='block'; });

    controls.addEventListener('lock', () => {
      ui.style.display = 'none';
      hud.style.display = 'flex';
      crosshair.style.display = 'block';
      document.getElementById('weaponImg').style.display = 'block';
      startGame();
    });
    controls.addEventListener('unlock', () => {
      hud.style.display = 'none';
      crosshair.style.display = 'none';
      document.getElementById('weaponImg').style.display = 'none';
      ui.style.display = 'grid';
      const completed = finishedResult?.completed === true;
      endGame(completed);
      finishedResult = null;
    });

    function startGame(){
      // reset state
      playing = true;
      hitCount = 0;
      shotsCount = 0;
      finishedResult = null;
      startTime = performance.now();
      camera.position.set(0, 1.6, 3);
      updateHUD();
      clearBalloons();
      spawnBalloon();
    }

    function endGame(completed){
      playing = false;
      clearBalloons();

      const elapsed = performance.now() - startTime;
      const accuracy = shotsCount > 0 ? (hitCount / shotsCount) : 0;

      if (completed){
        const stats = loadStats();
        stats.attempts += 1;
        const current = { timeMs: elapsed, hits: hitCount, shots: shotsCount, accuracy, dateISO: new Date().toISOString(), targets: totalTargets };
        if (!stats.best || current.timeMs < stats.best.timeMs) stats.best = current;
        saveStats(stats);
        renderRecords();

        document.getElementById('resHits').textContent = `${hitCount}`;
        document.getElementById('resShots').textContent = `${shotsCount}`;
        document.getElementById('resAccuracy').textContent = `${Math.round(accuracy*100)}%`;
        document.getElementById('resTime').textContent = `${(elapsed/1000).toFixed(3)}s`;
        document.getElementById('resTargets').textContent = `${totalTargets}`;

        startPanel.style.display = 'none';
        resultPanel.style.display = 'block';
      } else {
        startPanel.querySelector('.sub').textContent = `点击开始进入第一人称，射击 ${totalTargets} 个气球结束`;
        resultPanel.style.display = 'none';
        startPanel.style.display = 'block';
      }
    }

    function updateHUD(){
      counterEl.textContent = `${hitCount} / ${totalTargets}`;
    }

    function updateTimer(){
      if (!playing) return;
      const ms = performance.now() - startTime;
      timerEl.textContent = `${(ms/1000).toFixed(3)}s`;
    }

    function clearBalloons(){
      for (let i = balloonGroup.children.length - 1; i >= 0; i--) {
        const obj = balloonGroup.children[i];
        obj.geometry?.dispose?.();
        obj.material?.dispose?.();
        balloonGroup.remove(obj);
      }
    }

    function createBalloon(){
      const radius = THREE.MathUtils.randFloat(options.sizeMin, options.sizeMax);
      const geo = new THREE.SphereGeometry(radius, 24, 24);
      const color = new THREE.Color().setHSL(Math.random(), 0.6, 0.55);
      const mat = new THREE.MeshStandardMaterial({ color, metalness: 0.05, roughness: 0.25, envMapIntensity: 1.0 });
      const mesh = new THREE.Mesh(geo, mat);
      mesh.userData.radius = radius;
      // floating animation params
      mesh.userData.bobOffset = Math.random() * Math.PI * 2;
      return mesh;
    }

    function randomSpawnPosition(){
      // spawn within the visible region near screen center using NDC box
      const ndcX = THREE.MathUtils.randFloatSpread(options.ndcBox * 2); // [-ndcBox, ndcBox]
      const ndcY = THREE.MathUtils.randFloatSpread(options.ndcBox * 2);
      const distance = THREE.MathUtils.randFloat(options.distMin, options.distMax);

      // Create a point in clip space at depth 0.5 and unproject to get a world direction
      const ndc = new THREE.Vector3(ndcX, ndcY, 0.5);
      ndc.unproject(camera);
      const dir = ndc.sub(camera.position).normalize();
      const worldPos = camera.position.clone().add(dir.multiplyScalar(distance));

      // Keep within reasonable height
      worldPos.y = THREE.MathUtils.clamp(worldPos.y, 0.8, 3.0);
      return worldPos;
    }

    function spawnBalloon(){
      const balloon = createBalloon();
      const pos = randomSpawnPosition();
      balloon.position.copy(pos);
      balloonGroup.add(balloon);
    }

    function onShoot(){
      if (!playing) return;
      shotsCount++;
      // recoil animation
      const weapon = document.getElementById('weaponImg');
      if (weapon){
        weapon.classList.remove('kick');
        // force reflow to restart animation
        // eslint-disable-next-line no-unused-expressions
        void weapon.offsetWidth;
        weapon.classList.add('kick');
      }
      // cast from camera center
      raycaster.setFromCamera(new THREE.Vector2(0,0), camera);
      const intersects = raycaster.intersectObjects(balloonGroup.children, false);
      if (intersects.length > 0){
        const hit = intersects[0].object;
        // pop effect: scale up then remove
        const obj = hit;
        const pop = { s: 1 };
        const start = performance.now();
        const dur = 120; // ms
        const baseScale = obj.scale.x;
        function tick(){
          const t = Math.min(1, (performance.now()-start)/dur);
          const ease = t<0.5 ? (t*t*2) : (1-(1-t)*(1-t)*2);
          const s = baseScale * (1 + 0.6*ease);
          obj.scale.setScalar(s);
          if (t < 1) requestAnimationFrame(tick); else {
            balloonGroup.remove(obj);
            obj.geometry.dispose(); obj.material.dispose();
          }
        }
        requestAnimationFrame(tick);

        hitCount++;
        updateHUD();
        if (hitCount >= totalTargets){
          finishedResult = { completed: true };
          controls.unlock();
        } else {
          spawnBalloon();
        }
      }
    }

    // input
    const keyMap = { 'KeyW':'forward', 'KeyS':'backward', 'KeyA':'left', 'KeyD':'right' };
    window.addEventListener('keydown', (e)=>{
      const k = keyMap[e.code]; if (k) move[k] = true;
      if (e.code === 'Space') onShoot();
    });
    window.addEventListener('keyup', (e)=>{ const k = keyMap[e.code]; if (k) move[k] = false; });
    renderer.domElement.addEventListener('mousedown', (e)=>{ if (e.button===0) onShoot(); });

    function animate(){
      requestAnimationFrame(animate);

      // movement
      const dt = Math.min(0.05, renderer.info.render.frame > 0 ? (1/60) : (1/60));
      velocity.set(0,0,0);
      if (move.forward) velocity.z -= 1;
      if (move.backward) velocity.z += 1;
      if (move.left) velocity.x -= 1;
      if (move.right) velocity.x += 1;
      if (velocity.lengthSq() > 0) velocity.normalize().multiplyScalar(speed * dt);
      controls.moveRight(velocity.x);
      controls.moveForward(-velocity.z);

      // balloons bobbing
      const t = performance.now() * 0.001;
      for (const b of balloonGroup.children){
        const o = b.userData.bobOffset;
        b.position.y += Math.sin(t*2 + o) * 0.002; // subtle bob
      }

      updateTimer();
      renderer.render(scene, camera);
    }
    animate();

    window.addEventListener('resize', ()=>{
      const w = window.innerWidth, h = window.innerHeight;
      renderer.setSize(w,h);
      camera.aspect = w/h; camera.updateProjectionMatrix();
    });
  </script>
</body>
</html>


